iT邦幫忙

2023 iThome 鐵人賽

DAY 29
0
Software Development

Spring Boot 零基礎入門系列 第 29

Spring Boot 零基礎入門 (29) - 實戰演練 - 打造一個簡單的圖書館系統

  • 分享至 

  • xImage
  •  

賀!此系列文榮獲 2023 iThome 鐵人賽《優選》獎項,正在規劃出書中,感謝大家的支持🙏,同名課程「Java 工程師必備!Spring Boot 零基礎入門」也已在 Hahow 平台上架

哈囉大家好,我是古古

在前面的文章中,我們有介紹了 Spring Boot 中的常見基本用法,包含 Spring IoC、Spring AOP、Spring MVC(和前端溝通)、以及 Spring JDBC(和資料庫溝通),也有介紹了 MVC 架構模式:Controller-Service-Dao 的三層式架構

所以接著這篇文章,我們就會來做個實戰演練,結合上述的功能,去創建一個圖書館的管理系統出來,所以我們就開始吧!

補充:建議可以搭配 GitHub 上的 springboot-library 程式一起觀看,效果更佳

功能分析 - 圖書館管理系統


在我們開始動手寫程式去實作圖書館管理系統之前,可以先來分析,我們在這個圖書館管理系統中,想要提供什麼樣的功能

圖書館管理系統他顧名思義,就是來管理「書」的嘛,所以我們針對「書」這個資源,就可以去實作去他的 CRUD 四大基本操作,也就是「新增一本書」、「查詢某一本書」、「更新某本書的資訊」、「刪除某一本書」

因此下面就會分別來介紹,要如何在 Spring Boot 中設計和實作出這四個功能,完成一個簡易的圖書館管理系統

資料庫 table 設計


在設計資料庫 table 時,因為是針對「書本」這個資源做展開,因此我們可以設計一個 book table,儲存書本的相關資訊,以下是創建 book table 的 sql 語法

CREATE TABLE book
(
    book_id            INT          NOT NULL PRIMARY KEY AUTO_INCREMENT,
    title              VARCHAR(128) NOT NULL,
    author             VARCHAR(32)  NOT NULL,
    image_url          VARCHAR(256) NOT NULL,
    price              INT          NOT NULL,
    published_date     TIMESTAMP    NOT NULL,
    created_date       TIMESTAMP    NOT NULL,
    last_modified_date TIMESTAMP    NOT NULL
);

其中各個欄位的含義如下:

  • book_id:表示 book 的唯一 id,由資料庫自動增長
  • title:表示此書的書名
  • author:表示此書的作者
  • image_url:儲存此書的圖片連結
  • price:此書的販售價格
    • 補充:在實務上只要牽扯到「金錢」的部分,通常會用 BigDecimal 做特別處理,不過由於此 project 主要在練習 Spring Boot 的基礎結構和用法,因此此處使用 Integer 類型來簡化實作細節
  • published_date:此書的上架時間
  • created_date:創建此條數據的時間
  • last_modified_date:最後修改此條數據的時間

補充:建議在 myjdbc 這個 database 底下執行這個 CREATE TABLE 的語法,因為後續的 Spring Boot 程式會設定成去連線到 myjdbc database

1. 實作「查詢某一本書」的功能


通常在實作 CRUD 功能的時候,第一個建議實作的功能即是「查詢一本書」的功能

BookController(Controller 層)的實作

因此我們可以先在 BookController 中,添加如下的程式

補充:由於程式的篇幅過長,因此建議可以同步對照 GitHub 中的程式,此處就只會貼重點部分

@GetMapping("/books/{bookId}")
public ResponseEntity<Book> getBook(@PathVariable Integer bookId) {
    Book book = bookService.getBookById(bookId);

    if (book != null) {
        return ResponseEntity.status(HttpStatus.OK).body(book);
    } else {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    }
}

在這個 BookController 中,因為他是屬於 Controller 層,所以他會使用 @GetMapping,去接住前端放在 url 路徑中的 bookId 的參數

而在接到 bookId 的值之後,就會直接往後傳給 BookService 去做後續處理,將商業邏輯寫在 Service 層,讓 Controller 層保持「和前端溝通」的部分

當 BookService 返回查詢到的 book 數據之後,BookController 就可以根據是否有查詢到數據與否,去決定要返回給前端的 Http status code

像是下面的程式中,if 區塊就呈現出「當 book 不為 null 時,就返回 200 OK 的 Http status code,並且把 book 數據放在 response body 中」的資訊,並且在 else 區塊中,「當 book 為 null 時,就返回 404 Not Found 的 Http status code 給前端」

if (book != null) {
    return ResponseEntity.status(HttpStatus.OK).body(book);
} else {
    return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}

補充:有關 Http status code 的部分,可以參考 Day 23 - Http status code(Http 狀態碼)介紹 的文章

BookService(Service 層)的實作

鏡頭來到 BookService,當 BookService 這個 Service 層收到 Controller 傳過來的 bookId 之後,因為這邊的邏輯比較簡單,因此就只需要直接去 call BookDao 的方法,由 Dao 層去查詢這一筆數據出來

public Book getBookById(Integer bookId) {
    return bookDao.getBookById(bookId);
}

BookDao(Dao 層)的實作

而在 BookDao 裡面,因為他是 Dao 層,是負責和資料庫進行溝通,因此就可以在這裡使用 Spring JDBC 的 query() 方法,從資料庫中去查詢這一筆 bookId 的數據出來

public Book getBookById(Integer bookId) {
    String sql = "SELECT book_id, title, author, image_url, price, published_date, created_date, last_modified_date " +
            "FROM book WHERE book_id = :bookId";

    Map<String, Object> map = new HashMap<>();
    map.put("bookId", bookId);

    List<Book> bookList = namedParameterJdbcTemplate.query(sql, map, new BookRowMapper());

    if (bookList.size() > 0) {
        return bookList.get(0);
    } else {
        return null;
    }
}

到這邊,「查詢某一本書」的功能就實作完畢了!

API Tester 實際測試

因為目前 book table 中沒有任何數據,因此在測試「查詢某一本書」功能之前,需要在資料庫的 console 執行以下的 sql,手動插入一筆數據到 book table 中

INSERT INTO book (title, author, image_url, price, published_date, created_date, last_modified_date) VALUES ('先問,為什麼?:顛覆慣性思考的黃金圈理論,啟動你的感召領導力', '賽門‧西奈克', 'https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/092/65/0010926506.jpg', 331, '2018-05-23 00:00:00', '2023-10-14 02:42:02', '2023-10-14 02:42:02');

接著就可以在 API Tester 中,輸入以下的參數設定

https://ithelp.ithome.com.tw/upload/images/20231014/20151036XRJtyiGqet.png

輸入完成按下 Send 鍵之後,結果如下

https://ithelp.ithome.com.tw/upload/images/20231014/20151036TyJjeIYeRf.png

2. 實作「新增一本書」的功能


實作完「查詢某一本書」的功能之後,第二個推薦實作的,就是「新增一本書」的功能

BookController(Controller 層)的實作

我們一樣是可以先在 BookController 中,添加以下的程式,去接住前端所傳過來的參數

@PostMapping("/books")
public ResponseEntity<Book> createBook(@RequestBody BookRequest bookRequest) {
    Integer bookId = bookService.createBook(bookRequest);

    Book book = bookService.getBookById(bookId);

    return ResponseEntity.status(HttpStatus.CREATED).body(book);
}

而在「新增一本書」時,通常會分成兩個步驟:

  1. 先去 call BookService 的 createBook() 方法,真的去資料庫中創建一筆 Book 數據出來
  2. 當資料庫創建好數據之後,查詢該筆 Book 數據出來,然後將這筆 Book 數據原封不動的回傳給前端

第一步比較單純,就是在 BookController 接收到前端傳過來的 BookRequest 參數之後,就將當往後丟給 BookService 去做處理,前端的請求如下:

{
  "title": "原子習慣:細微改變帶來巨大成就的實證法則",
  "author": "詹姆斯‧克利爾",
  "imageUrl": "https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/082/25/0010822522.jpg",
  "price": 260,
  "publishedDate": "2019-06-01 00:00:00"
}

https://ithelp.ithome.com.tw/upload/images/20231014/20151036f3L3ew7pBt.png

而第二步的實作與否,其實不會影響到「新增一本書」的具體功能(因為第二步只是查詢而已),因此可以看個人喜好決定是否想要實作第二步

BookService(Service 層)的實作

BookService 的實作在這邊也是比較簡單,因此只需要直接去 call BookDao,由 Dao 層去處理和資料庫的溝通,在資料庫中去創建一筆數據出來就可以了

public Integer createBook(BookRequest bookRequest) {
    return bookDao.createBook(bookRequest);
}

BookDao(Dao 層)的實作

而在 BookDao 裡面,因為他是 Dao 層,是負責和資料庫進行溝通,因此就可以在這裡使用 Spring JDBC 的 update() 方法,在資料庫中新增一筆 Book 的數據

public Integer createBook(BookRequest bookRequest) {
    String sql = "INSERT INTO book(title, author, image_url, price, published_date, created_date, last_modified_date) " +
            "VALUES (:title, :author, :imageUrl, :price, :publishedDate, :createdDate, :lastModifiedDate)";

    Map<String, Object> map = new HashMap<>();
    map.put("title", bookRequest.getTitle());
    map.put("author", bookRequest.getAuthor());
    map.put("imageUrl", bookRequest.getImageUrl());
    map.put("price", bookRequest.getPrice());
    map.put("publishedDate", bookRequest.getPublishedDate());

    Date now = new Date();
    map.put("createdDate", now);
    map.put("lastModifiedDate", now);

    KeyHolder keyHolder = new GeneratedKeyHolder();

    namedParameterJdbcTemplate.update(sql, new MapSqlParameterSource(map), keyHolder);

    int bookId = keyHolder.getKey().intValue();

    return bookId;
}

這裡比較值得注意的是後面的 KeyHolder 的用法,這個算是 update() 的一種進階用法,即是在創建一筆數據到資料庫時,可以取得到「由資料庫所生成的 id」

因為像是我們在設計 book table 時,其中的 book_id 欄位,我們是設定成 AUTO_INCREMENT,而這就會導致一種現象,即是「我們在 Spring Boot 中插入了一筆數據,但是我們卻不知道這筆數據的 id 值是多少」

book_id  INT NOT NULL PRIMARY KEY AUTO_INCREMENT,

因此 KeyHolder 就是為了解決這個問題而誕生的,只要在執行 update() 方法時,同時也加入一個 KeyHolder 的參數,這樣子就可以取得「資料庫所自動產生的 id 的值」了

而到這邊,我們也就完成了「新增一本書」的功能實作了!

API Tester 實際測試

要測試「新增一本書」的功能的話,只需要在 API Tester 中,輸入以下的參數設定:

{
  "title": "原子習慣:細微改變帶來巨大成就的實證法則",
  "author": "詹姆斯‧克利爾",
  "imageUrl": "https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/082/25/0010822522.jpg",
  "price": 260,
  "publishedDate": "2019-06-01 00:00:00"
}

https://ithelp.ithome.com.tw/upload/images/20231014/20151036Sf7NwyifC6.png

輸入完成按下 Send 鍵之後,結果如下

https://ithelp.ithome.com.tw/upload/images/20231014/20151036fnFqRQNe4T.png

3. 實作「更新某一本書」的功能


實作完「新增一本書」的功能之後,接著可以來實作「更新某一本書」的功能

BookController(Controller 層)的實作

我們一樣是可以先在 BookController 中,添加以下的程式,去接住前端所傳過來的參數

@PutMapping("/books/{bookId}")
public ResponseEntity<Book> updateBook(@PathVariable Integer bookId,
                                       @RequestBody BookRequest bookRequest) {
    // 檢查 book 是否存在
    Book book = bookService.getBookById(bookId);

    if (book == null) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    }

    // 修改 Book 的數據
    bookService.updateBook(bookId, bookRequest);

    Book updatedBook = bookService.getBookById(bookId);

    return ResponseEntity.status(HttpStatus.OK).body(updatedBook);
}

在「更新某一本書」時,一樣是會分成兩個步驟來進行:

  1. 先檢查此 Book 是否存在,如果不存在,即返回 404 Not Found 的資訊給前端
  2. 如果此 Book 存在,則修改該 Book 的數據,並返回修改後的 Book 數據給前端

之所以會分成兩個步驟來執行,是因為透過這樣子的寫法,可以讓前端知道「他具體遇到的是什麼狀況」,譬如說當前端拿到 404 Not Found,他就知道是他的 bookId 參數寫錯,嘗試去更新一個不存在的 Book;如果當前端拿到的是 200 OK,就表示更新 Book 數據成功

假設我們不分成兩步驟來做,而是一拿到前端的 bookId 參數,然後直接去根據該 bookId 來更新數據的話,雖然資料庫的執行上不會出問題,但是對於前端來說,他卻會拿到一個 200 OK 的成功回應

因此從這個角度來看,就會變成是「前端明明嘗試更新一筆不存在的數據,但是我們卻跟他說 OK 更新成功」,邏輯上不是很合理

所以此處才會分成兩步驟,先判斷 Book 是否存在,如果存在,才更新該 Book,讓我們的後端程式可以更忠實的呈現正確的邏輯

BookService(Service 層)的實作

BookService 的實作在這邊也是比較簡單,所以就只需要直接去 call BookDao,由 Dao 層去處理和資料庫的溝通,在資料庫中去修改這筆 Book 數據就可以了

public void updateBook(Integer bookId, BookRequest bookRequest) {
    bookDao.updateBook(bookId, bookRequest);
}

BookDao(Dao 層)的實作

而在 BookDao 裡面,則可以使用 Spring JDBC 的 update() 方法,在資料庫中去更新這一筆 book 的數據

public void updateBook(Integer bookId, BookRequest bookRequest) {
    String sql = "UPDATE book SET title = :title, author = :author, image_url = :imageUrl, " +
            "price = :price, published_date = :publishedDate, last_modified_date = :lastModifiedDate" +
            " WHERE book_id = :bookId ";

    Map<String, Object> map = new HashMap<>();
    map.put("bookId", bookId);

    map.put("title", bookRequest.getTitle());
    map.put("author", bookRequest.getAuthor());
    map.put("imageUrl", bookRequest.getImageUrl());
    map.put("price", bookRequest.getPrice());
    map.put("publishedDate", bookRequest.getPublishedDate());

    map.put("lastModifiedDate", new Date());

    namedParameterJdbcTemplate.update(sql, map);
}

這裡需要注意的地方,是在更新數據時,記得要同步去更新 book table 中的 last_modified_date 欄位的值,將他更新成當前的時間

原因是因為,這個 last_modified_date 欄位的含義,是「最後修改此筆數據的時間」,讓其他人可以知道,這筆數據在什麼時候被修改過,因此只要是對 book table 的每一筆數據進行更新,就要記得同時也去更新 last_modified_date,確保這個值永遠是最新的

到這邊,我們也就完成了「更新某一本書」的功能實作了!

API Tester 實際測試

假設我們要更新 id 為 2 的那本書,那就只需要在 API Tester 中,輸入以下的參數設定:

{
  "title": "Atomic Habits: An Easy & Proven Way to Build Good Habits & Break Bad Ones",
  "author": "James Clear",
  "imageUrl": "https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/082/25/0010822522.jpg",
  "price": 1000000,
  "publishedDate": "2019-06-01 00:00:00"
}

https://ithelp.ithome.com.tw/upload/images/20231014/20151036aHCyKZtxnk.png

輸入完成按下 Send 鍵之後,結果如下

https://ithelp.ithome.com.tw/upload/images/20231014/20151036dEFYvJsWw0.png

4. 實作「刪除某一本書」的功能


實作完上述的功能之後,最後則是來實作「刪除某一本書」的功能

BookController(Controller 層)的實作

我們一樣是可以先在 BookController 中,添加以下的程式,去接住前端所傳過來的參數

@DeleteMapping("/books/{bookId}")
public ResponseEntity<?> deleteBook(@PathVariable Integer bookId) {
    bookService.deleteBookById(bookId);

    return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}

在實作「刪除某一本書」的功能時,他的設計理念,又和上面的「更新某一本書」有點不一樣了

在上面的「更新某一本書」中,我們會先去檢查該書是否存在,然後再根據該書是否存在,去返回不同的 Http status code 給前端,但是在實作「刪除某一本書」時,不管這本書存不存在,我們就只要通通回傳成功的 204 No Content 的 Http status code 給前端即可

會造成這個原因是因為,在「刪除某一本書」的觀念裡,他的目的就是「要去刪除一本書」,換句話說的話,就是「要讓那本書的數據從地球上消失」,因此會有兩種情況:

  1. 假設這本書存在,我們刪除他,所以這本書的數據就不存在了,完美!因此回傳成功的 204 No Content 給前端
  2. 假設這本書不存在,即使我們不用刪,在邏輯意義上,這本書的數據也是不存在的,一樣完美!因此也是回傳成功的 204 No Content 給前端

所以對於「刪除某一本書」的功能來說,他在意的「不是」有沒有真的刪除到數據,他在意的,是「該數據是否真的消失了」,只要數據消失,不管他是曾出現過然後被刪除、或是從來就沒出現過,對於「刪除某一本書」這個功能來說,就通通都是回成功的 204 No Content 就對了!

BookService(Service 層)的實作

BookService 的實作在這邊也是比較簡單,所以就只需要直接去 call BookDao,由 Dao 層去處理和資料庫的溝通,在資料庫中去刪除這筆 Book 數據就可以了

public void deleteBookById(Integer bookId) {
    bookDao.deleteBookById(bookId);
}

BookDao(Dao 層)的實作

而在 BookDao 裡面,則可以使用 Spring JDBC 的 update() 方法,在資料庫中去刪除這一筆 book 的數據

public void deleteBookById(Integer bookId) {
    String sql = "DELETE FROM book WHERE book_id = :bookId ";

    Map<String, Object> map = new HashMap<>();
    map.put("bookId", bookId);

    namedParameterJdbcTemplate.update(sql, map);
}

所以到這邊,我們也就完成了「刪除某一本書」的功能實作了!

API Tester 實際測試

假設我們要刪除 id 為 1 的那本書,那就只需要在 API Tester 中,輸入以下的參數設定:

https://ithelp.ithome.com.tw/upload/images/20231014/20151036G4ioFPz4JV.png

輸入完成按下 Send 鍵之後,結果如下

https://ithelp.ithome.com.tw/upload/images/20231014/20151036wEJhP5JDAX.png

小結:圖書館管理系統


透過以上的四個功能「新增一本書」、「查詢某一本書」、「更新某一本書的資訊」、「刪除某一本書」的實作,我們就完成了針對「書本」這個資源的 CRUD 的四大基本操作了,因此對於圖書館管理員來說,就可以透過這個後端系統,在資料庫中去新增、刪除、查詢、和修改裡面的書本了

不過,對於一個圖書館管理系統來說,其實單單只有 CRUD 的功能是不夠滿足實際的需求的

像是管理員可能需要「查詢書本列表」的功能,根據出版時間、價格、名字...等等的因素,去列出符合條件的書本有哪些

又或是管理員可能想要「權限管理」的功能,只讓正職員工擁有「新增、修改、刪除」書本的功能,而讓實習生只有「查詢」書本的功能,避免實習生誤操作,使得資料庫中的書本資料被刪除

因此針對這樣子的一個圖書館管理系統,背後還是有許多功能可以延伸的,不過這些強大的功能,都是建立在 CRUD 功能已經完成的前提下,才有辦法往下延伸,因此作為 Spring Boot 的基礎,具備實作出一個 CRUD 功能的能力還是滿重要的,所以希望前面的 Spring Boot 基礎介紹文章可以幫助到大家,一起打好 CRUD 的基本功💪

總結


這篇文章先進行了圖書館管理系統的功能分析,針對「書本」這個資源,去設計了 CRUD 的四大基本操作,並且也有實際的在 Spring Boot 中,結合前面文章所介紹的內容,去實作出這四個功能

那麼有關 Spring Boot 的基礎入門介紹,就到這邊告一個段落了,下一篇文章就會來總結一下,我們在這 30 天中都介紹了哪些知識,做一個大總結,那我們就下一篇文章見啦!

相關連結



上一篇
Spring Boot 零基礎入門 (28) - MVC 架構模式 - Controller-Service-Dao 三層式架構
下一篇
Spring Boot 零基礎入門 (30) - Spring Boot 零基礎入門總結
系列文
Spring Boot 零基礎入門30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

1

您好,我想請問實作「新增一本書」這一個部分的功能
我的 API Tester 顯示 400 Bad Request
Controller Service Dao 以及 Request 的程式碼我確認都沒有問題

Spring Console 的報錯為:
Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type java.util.Date from String "2019-06-01 00:00:00": not a valid representation (error: Failed to parse Date value '2019-06-01 00:00:00': Cannot parse date "2019-06-01 00:00:00": while it seems to fit format 'yyyy-MM-dd'T'HH:mm:ss.SSSX', parsing fails (leniency? null))]

您的文章曾經提過

前端傳給後端的參數名稱不同、或是說請求的格式有問題,都是可以被歸類在 400 這個 status code,所以以後只要看到 400,通常第一件事,就是回頭檢查一下是不是請求的參數寫錯了

加上報錯好像也有提到格式不匹配的問題,請問是否是
這一段參數設定的

{
  "title": "原子習慣:細微改變帶來巨大成就的實證法則",
  "author": "詹姆斯‧克利爾",
  "imageUrl": "https://im1.book.com.tw/image/getImage?i=https://www.books.com.tw/img/001/082/25/0010822522.jpg",
  "price": 260,
  "publishedDate": "2019-06-01 00:00:00"
}

"publishedDate": "2019-06-01 00:00:00" 這個部分的設定有問題呢?

不得不說 ChatGPT 還真的是好用xD ,只不過你得先有耐心地將你的問題統整好後,完整地敘述給它聽。
以下為這個問題解決的方法,如果您有遇到同樣的問題,可以參考看看。

在BookRequest 以及 Book 這兩個Class中的

private Date publishedDate;

加上

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")

變成

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date publishedDate;

透過這個註解来指定日期的解析格式
我的部分是加入這個註解之後,API Tester 測試就是 201 成功了

資料庫也順利新增一筆資料了!

f88083 iT邦新手 5 級 ‧ 2024-04-07 00:22:47 檢舉

謝謝分享

f88083 iT邦新手 5 級 ‧ 2024-04-24 18:29:45 檢舉

後來發現作者的github程式碼中有把相關的設定加入到application.properties裡面,就無需這個annotation了

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/myjdbc?serverTimezone=Asia/Taipei&characterEncoding=utf-8
spring.datasource.username=root
spring.datasource.password=springboot

spring.jackson.time-zone=GMT+8
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss

原始程式碼

0
f88083
iT邦新手 5 級 ‧ 2024-04-07 00:24:48

完整的把所有文章看過了,感謝你的系列文章,收穫良多,字詞簡潔明瞭,解釋通暢直白,感激不盡!!

古古 iT邦新手 4 級 ‧ 2024-04-11 20:15:25 檢舉

噢噢噢有幫助到你就好!感謝感謝~

我要留言

立即登入留言